--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 56eaeafeec8f3dae889744f8c5979e7ca2205240
Parents : 63189f4
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-09T09:20:55-05:00
feat(Reticulum): improve hub management with configurable announce intervals and presence group handling, including UI updates for better user experience.
Changes
30 files changed, 1439 insertions(+), 94 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index cc51db2f..fa07349d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -8830,12 +8830,17 @@ class ReticulumMeshChat:
greeting = (data.get("greeting") or "").strip() or None
announce = bool(data.get("announce", True))
enabled = bool(data.get("enabled", True))
- hub = manager.create_hub(
- name=name,
- greeting=greeting,
- announce=announce,
- enabled=enabled,
- )
+ create_kwargs = {
+ "name": name,
+ "greeting": greeting,
+ "announce": announce,
+ "enabled": enabled,
+ }
+ if "announce_interval_seconds" in data:
+ create_kwargs["announce_interval_seconds"] = data.get(
+ "announce_interval_seconds",
+ )
+ hub = manager.create_hub(**create_kwargs)
return web.json_response({"hub": hub.to_dict()})
@routes.delete("/api/v1/rrc/servers/{hub_id}")
@@ -8861,6 +8866,11 @@ class ReticulumMeshChat:
name=(data.get("name") if "name" in data else None),
greeting=(data.get("greeting") if "greeting" in data else None),
announce=(data.get("announce") if "announce" in data else None),
+ announce_interval_seconds=(
+ data.get("announce_interval_seconds")
+ if "announce_interval_seconds" in data
+ else None
+ ),
trusted_identities=(
data.get("trusted_identities")
if "trusted_identities" in data
diff --git a/meshchatx/src/backend/rrc/manager.py b/meshchatx/src/backend/rrc/manager.py
index 9da3a098..f7160085 100644
--- a/meshchatx/src/backend/rrc/manager.py
+++ b/meshchatx/src/backend/rrc/manager.py
@@ -88,6 +88,7 @@ class RRCHub:
self._manual_disconnect = False
self._reconnect_attempts = 0
self._reconnect_timer = None
+ self._had_session = False
self._pending_pings = {}
self._last_history_clean = 0
self.clean_last_removed = 0
@@ -418,6 +419,9 @@ class RRCHub:
def _on_closed(self, link):
self._stop_hello.set()
with self._lock:
+ was_welcomed = self.welcomed
+ rooms = list(self.rooms)
+ manual = self._manual_disconnect
self.link = None
self.welcomed = False
self.motd = None
@@ -428,6 +432,9 @@ class RRCHub:
self._silent_joins.clear()
self._silent_who_rooms.clear()
should_reconnect = self.auto_reconnect and not self._manual_disconnect
+ if was_welcomed and rooms:
+ text = "Disconnected from hub" if manual else "Connection lost"
+ self._record_connection_event(text, rooms=rooms)
self._set_status(RRCHub.STATUS_DISCONNECTED, "Disconnected")
if should_reconnect:
self._schedule_reconnect()
@@ -882,6 +889,15 @@ class RRCHub:
self._append_history(room, msg)
self._clean_history()
+ def _record_connection_event(self, text, rooms=None):
+ """Write a connection status line into each joined room timeline."""
+ if rooms is None:
+ with self._lock:
+ rooms = list(self.rooms)
+ for room in rooms:
+ with contextlib.suppress(Exception):
+ self._record_system(room, text)
+
def _record_notice(self, msg):
target_room = msg.room
if not target_room:
@@ -964,9 +980,14 @@ class RRCHub:
limits = body.get(proto.B_WELCOME_LIMITS)
if isinstance(limits, dict):
self._apply_limits(limits)
- self._set_status(RRCHub.STATUS_CONNECTED, "Connected")
with self._lock:
+ was_reconnect = self._had_session
self._reconnect_attempts = 0
+ self._had_session = True
+ rooms = list(self.rooms)
+ self._set_status(RRCHub.STATUS_CONNECTED, "Connected")
+ if was_reconnect and rooms:
+ self._record_connection_event("Reconnected to hub", rooms=rooms)
self.manager._on_welcome(self)
if self.auto_list:
self._request_room_list()
@@ -1029,7 +1050,9 @@ class RRCHub:
self.nicks[jh] = joiner_nick
if self_join:
- if not silent:
+ if silent:
+ self._record_system(r, "You rejoined #" + r)
+ else:
self._record_system(r, "You joined #" + r)
if self.auto_who:
try:
diff --git a/meshchatx/src/backend/rrc/server.py b/meshchatx/src/backend/rrc/server.py
index c41db8e2..c366df8c 100644
--- a/meshchatx/src/backend/rrc/server.py
+++ b/meshchatx/src/backend/rrc/server.py
@@ -29,6 +29,26 @@ MESSAGE_LOG_CAP = 5000
STORE_FILENAME = "hubs"
HUB_CONFIG_FILENAME = "hub.toml"
ROOMS_FILENAME = "rooms.toml"
+DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900
+MIN_ANNOUNCE_INTERVAL_SECONDS = 60
+MAX_ANNOUNCE_INTERVAL_SECONDS = 86400
+
+
+def normalize_announce_interval_seconds(
+ value, default=DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+):
+ """Clamp announce interval to a supported range, or 0 to disable periodic announces."""
+ if value is None:
+ return int(default)
+ try:
+ seconds = int(value)
+ except (TypeError, ValueError):
+ return int(default)
+ if seconds <= 0:
+ return 0
+ return max(
+ MIN_ANNOUNCE_INTERVAL_SECONDS, min(MAX_ANNOUNCE_INTERVAL_SECONDS, seconds)
+ )
class _LoopbackEndpoint:
@@ -90,6 +110,7 @@ class RRCHubServer:
name=None,
greeting=None,
announce=True,
+ announce_interval_seconds=DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
enabled=True,
):
self.manager = manager
@@ -98,6 +119,9 @@ class RRCHubServer:
self.name = name or ("Hub " + identity.hash.hex()[:8])
self.greeting = greeting
self.announce = announce
+ self.announce_interval_seconds = normalize_announce_interval_seconds(
+ announce_interval_seconds,
+ )
self.enabled = enabled
self.max_nick_bytes = proto.DEFAULT_MAX_NICK_BYTES
@@ -109,6 +133,7 @@ class RRCHubServer:
self.destination = None
self.running = False
self._started_at = None
+ self._announce_timer = None
self._lock = threading.RLock()
self._sessions = {}
@@ -149,6 +174,7 @@ class RRCHubServer:
self._started_at = time.time()
if self.announce:
self.announce_now()
+ self._sync_announce_timer()
self._log("hub started at " + self.dest_hash.hex())
def announce_now(self):
@@ -159,7 +185,47 @@ class RRCHubServer:
app_data=proto.encode({"proto": "rrc", "v": 1, "hub": self.name}),
)
+ def _cancel_announce_timer(self):
+ timer = self._announce_timer
+ self._announce_timer = None
+ if timer is not None:
+ with contextlib.suppress(Exception):
+ timer.cancel()
+
+ def _sync_announce_timer(self):
+ self._cancel_announce_timer()
+ if not self.running or not self.announce:
+ return
+ interval = normalize_announce_interval_seconds(
+ self.announce_interval_seconds,
+ default=0,
+ )
+ if interval <= 0:
+ return
+ timer = threading.Timer(interval, self._announce_timer_fire)
+ timer.daemon = True
+ self._announce_timer = timer
+ timer.start()
+
+ def _announce_timer_fire(self):
+ self._announce_timer = None
+ if not self.running or not self.announce:
+ return
+ with contextlib.suppress(Exception):
+ self.announce_now()
+ self._sync_announce_timer()
+
+ def set_announce_settings(self, announce=None, announce_interval_seconds=None):
+ if announce is not None:
+ self.announce = bool(announce)
+ if announce_interval_seconds is not None:
+ self.announce_interval_seconds = normalize_announce_interval_seconds(
+ announce_interval_seconds,
+ )
+ self._sync_announce_timer()
+
def stop(self):
+ self._cancel_announce_timer()
with self._lock:
links = list(self._sessions.keys())
self._sessions.clear()
@@ -919,6 +985,7 @@ class RRCHubServer:
"running": self.running,
"uptime_seconds": uptime_seconds,
"announce": self.announce,
+ "announce_interval_seconds": self.announce_interval_seconds,
"greeting": self.greeting,
"clients": sum(1 for s in self._sessions.values() if s.welcomed),
"trusted_identities": policy["trusted_identities"],
@@ -949,6 +1016,7 @@ class RRCHubServer:
"name": self.name,
"enabled": self.enabled,
"announce": self.announce,
+ "announce_interval_seconds": self.announce_interval_seconds,
"greeting": self.greeting,
"trusted_identities": policy["trusted_identities"],
"banned_identities": policy["banned_identities"],
@@ -1004,7 +1072,14 @@ class RRCServerManager:
return hub
return None
- def create_hub(self, name=None, greeting=None, announce=True, enabled=True):
+ def create_hub(
+ self,
+ name=None,
+ greeting=None,
+ announce=True,
+ announce_interval_seconds=DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ enabled=True,
+ ):
identity = RNS.Identity()
os.makedirs(self._server_dir(), exist_ok=True)
identity.to_file(self._identity_path(identity.hash.hex()))
@@ -1014,6 +1089,7 @@ class RRCServerManager:
name=name,
greeting=greeting,
announce=announce,
+ announce_interval_seconds=announce_interval_seconds,
enabled=enabled,
)
trusted = self._owner_trusted_hex_list()
@@ -1082,6 +1158,7 @@ class RRCServerManager:
name=None,
greeting=None,
announce=None,
+ announce_interval_seconds=None,
trusted_identities=None,
banned_identities=None,
):
@@ -1092,8 +1169,11 @@ class RRCServerManager:
hub.name = name
if greeting is not None:
hub.greeting = greeting or None
- if announce is not None:
- hub.announce = bool(announce)
+ if announce is not None or announce_interval_seconds is not None:
+ hub.set_announce_settings(
+ announce=announce,
+ announce_interval_seconds=announce_interval_seconds,
+ )
if trusted_identities is not None or banned_identities is not None:
hub.policy.apply_config(
trusted_list=trusted_identities,
@@ -1178,6 +1258,10 @@ class RRCServerManager:
name=entry.get("name"),
greeting=entry.get("greeting"),
announce=bool(entry.get("announce", True)),
+ announce_interval_seconds=entry.get(
+ "announce_interval_seconds",
+ DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ ),
enabled=bool(entry.get("enabled", True)),
)
hub.configure_storage(
diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py
index 09d33568..c61c9f60 100644
--- a/meshchatx/src/backend/self_check.py
+++ b/meshchatx/src/backend/self_check.py
@@ -58,6 +58,7 @@ SELF_CHECK_LABELS = {
"http_bots_status_good": "HTTP Bots Status ",
"http_security_good": "HTTP Server Security ",
"http_interfaces_good": "HTTP RNS Interfaces ",
+ "http_reticulum_instance_good": "HTTP RNS Instance ",
"http_identities_good": "HTTP Identities ",
"http_favourites_good": "HTTP Favourites ",
"http_telephone_good": "HTTP Telephone Status ",
@@ -607,6 +608,7 @@ _WEB_PROBE_KEYS = (
"http_bots_status_good",
"http_security_good",
"http_interfaces_good",
+ "http_reticulum_instance_good",
"http_identities_good",
"http_favourites_good",
"http_telephone_good",
@@ -747,6 +749,18 @@ async def _run_web_api_probes(app: Any) -> dict[str, dict[str, str]]:
"/api/v1/reticulum/interfaces",
require_nested=(("interfaces", dict),),
)
+ results["http_reticulum_instance_good"] = await _probe_json_get(
+ client,
+ "/api/v1/reticulum/instance",
+ require_nested=(("instance", dict),),
+ validate=lambda body: (
+ None
+ if isinstance(body.get("instance"), dict)
+ and "share_instance" in body["instance"]
+ and "local_hops_delta" in body["instance"]
+ else "instance missing share_instance/local_hops_delta"
+ ),
+ )
results["http_identities_good"] = await _probe_json_get(
client,
"/api/v1/identities",
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index 300f5817..33c47b5f 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -75,7 +75,7 @@
<div v-if="effectiveSidebarCollapsed" class="flex flex-1 flex-col items-center gap-1 py-2 px-1">
<button
type="button"
- class="rounded-xl border-2 border-dashed border-sem-border p-2 text-sem-fg-muted hover:border-sem-accent hover:text-sem-accent"
+ class="rounded-xl p-2 text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
:title="$t('relay_chat.add_hub')"
@click="openAddHub"
>
@@ -244,12 +244,22 @@
</ul>
<div v-if="availableRoomsFor(hub).length > 0" class="space-y-0.5">
- <div
- class="px-2.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted"
+ <button
+ type="button"
+ class="flex w-full items-center gap-1 px-2.5 pt-1 text-left text-[10px] font-semibold uppercase tracking-wide text-sem-fg-muted transition-colors hover:text-sem-fg"
+ @click="toggleAvailableRooms(hub.hub_hash)"
>
- {{ $t("relay_chat.available_rooms") }}
- </div>
- <ul class="space-y-0.5">
+ <MaterialDesignIcon
+ :icon-name="
+ isAvailableRoomsExpanded(hub.hub_hash)
+ ? 'chevron-down'
+ : 'chevron-right'
+ "
+ class="size-3.5 shrink-0"
+ />
+ <span class="truncate">{{ $t("relay_chat.available_rooms") }}</span>
+ </button>
+ <ul v-show="isAvailableRoomsExpanded(hub.hub_hash)" class="space-y-0.5">
<li
v-for="availableRoom in availableRoomsFor(hub)"
:key="availableRoom.name"
@@ -266,7 +276,7 @@
</span>
<button
type="button"
- :class="btnIconSm"
+ class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
:title="$t('relay_chat.join')"
@click.stop="joinAvailableRoom(hub, availableRoom.name)"
>
@@ -285,7 +295,7 @@
/>
<button
type="submit"
- class="inline-flex size-7 shrink-0 items-center justify-center border border-sem-border bg-sem-canvas text-sem-fg transition hover:bg-sem-surface/60"
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-sem-fg-muted transition-colors hover:bg-sem-surface/60 hover:text-sem-accent"
:title="$t('relay_chat.join_room')"
>
<MaterialDesignIcon icon-name="plus" class="size-4" />
@@ -615,7 +625,7 @@
<!-- discovery view -->
<div v-show="view === 'discovery'" class="flex-1 overflow-y-auto custom-scrollbar p-3 sm:p-4">
- <div class="mx-auto w-full max-w-3xl space-y-4">
+ <div class="mx-auto w-full max-w-3xl space-y-3">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">{{ $t("relay_chat.discovery_title") }}</h2>
@@ -644,7 +654,7 @@
<input
v-model="discoverySearch"
type="text"
- :placeholder="$t('relay_chat.discovery_search')"
+ :placeholder="$t('relay_chat.discovery_search', { count: discovered.length })"
class="input-field !pl-9"
@input="onDiscoverySearch"
/>
@@ -658,49 +668,56 @@
{{ $t("relay_chat.discovery_empty") }}
</div>
- <div
- v-for="node in discovered"
- :key="node.destination_hash"
- class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sem-border bg-sem-canvas p-4 transition-colors hover:border-sem-border-strong"
- >
- <div class="min-w-0 flex-1">
- <div class="flex items-center gap-2">
- <MaterialDesignIcon icon-name="forum-outline" class="size-4 shrink-0 text-sem-accent" />
- <span class="truncate font-semibold">{{ nodeName(node) }}</span>
+ <div class="space-y-2">
+ <div
+ v-for="node in discovered"
+ :key="node.destination_hash"
+ class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-sem-border bg-sem-canvas p-4 transition-colors hover:border-sem-border-strong"
+ >
+ <div class="min-w-0 flex-1">
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon
+ icon-name="forum-outline"
+ class="size-4 shrink-0 text-sem-accent"
+ />
+ <span class="truncate font-semibold">{{ nodeName(node) }}</span>
+ </div>
+ <button
+ type="button"
+ class="mt-1 flex items-center gap-1.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
+ :title="$t('relay_chat.copy_hash')"
+ @click="copyHash(node.destination_hash)"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
+ <span class="truncate">{{ formatHash(node.destination_hash) }}</span>
+ </button>
+ <div
+ class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-sem-fg-muted"
+ >
+ <span class="inline-flex items-center gap-1">
+ <MaterialDesignIcon icon-name="clock-outline" class="size-3.5" />
+ {{ $t("relay_chat.announced_ago", { time: timeAgo(node.updated_at) }) }}
+ </span>
+ <span v-if="node.hops != null" class="inline-flex items-center gap-1">
+ <MaterialDesignIcon icon-name="transit-connection-variant" class="size-3.5" />
+ {{ $t("relay_chat.hops_away", { count: node.hops }) }}
+ </span>
+ </div>
</div>
<button
+ v-if="isHubAdded(node.destination_hash)"
type="button"
- class="mt-1 flex items-center gap-1.5 font-mono text-xs text-sem-fg-muted hover:text-sem-accent"
- :title="$t('relay_chat.copy_hash')"
- @click="copyHash(node.destination_hash)"
+ :class="btnSecondary"
+ @click="openDiscovered(node)"
>
- <MaterialDesignIcon icon-name="content-copy" class="size-3.5" />
- <span class="truncate">{{ formatHash(node.destination_hash) }}</span>
+ <MaterialDesignIcon icon-name="open-in-app" class="size-4" />
+ {{ $t("relay_chat.discovery_open") }}
+ </button>
+ <button v-else type="button" :class="btnPrimary" @click="addFromDiscovery(node)">
+ <MaterialDesignIcon icon-name="plus" class="size-4" />
+ {{ $t("relay_chat.discovery_add") }}
</button>
- <div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-sem-fg-muted">
- <span class="inline-flex items-center gap-1">
- <MaterialDesignIcon icon-name="clock-outline" class="size-3.5" />
- {{ $t("relay_chat.announced_ago", { time: timeAgo(node.updated_at) }) }}
- </span>
- <span v-if="node.hops != null" class="inline-flex items-center gap-1">
- <MaterialDesignIcon icon-name="transit-connection-variant" class="size-3.5" />
- {{ $t("relay_chat.hops_away", { count: node.hops }) }}
- </span>
- </div>
</div>
- <button
- v-if="isHubAdded(node.destination_hash)"
- type="button"
- :class="btnSecondary"
- @click="openDiscovered(node)"
- >
- <MaterialDesignIcon icon-name="open-in-app" class="size-4" />
- {{ $t("relay_chat.discovery_open") }}
- </button>
- <button v-else type="button" :class="btnPrimary" @click="addFromDiscovery(node)">
- <MaterialDesignIcon icon-name="plus" class="size-4" />
- {{ $t("relay_chat.discovery_add") }}
- </button>
</div>
</div>
</div>
@@ -779,6 +796,14 @@
<MaterialDesignIcon icon-name="stop" class="size-4" />
{{ $t("relay_chat.host_stop") }}
</button>
+ <button
+ type="button"
+ :class="btnIcon"
+ :title="$t('relay_chat.host_hub_settings')"
+ @click="openHostHubSettings(hub)"
+ >
+ <MaterialDesignIcon icon-name="cog" class="size-4" />
+ </button>
<button
type="button"
:class="btnIcon"
@@ -802,12 +827,20 @@
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-sem-fg-muted">
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="account-group" class="size-3.5" />
- {{ hub.clients }} {{ $t("relay_chat.host_clients") }}
+ {{ hub.clients }} {{ $t("relay_chat.host_users") }}
</span>
<span class="inline-flex items-center gap-1">
<MaterialDesignIcon icon-name="pound" class="size-3.5" />
{{ hub.rooms.length }} {{ $t("relay_chat.host_rooms") }}
</span>
+ <span v-if="hub.running" class="inline-flex items-center gap-1">
+ <MaterialDesignIcon icon-name="clock-outline" class="size-3.5" />
+ {{
+ $t("relay_chat.host_moderation_uptime", {
+ time: formatUptime(hostedHubUptimeSeconds(hub)),
+ })
+ }}
+ </span>
</div>
<button
@@ -850,10 +883,53 @@
class="input-field"
/>
</div>
- <label class="flex items-center gap-2 text-sm">
- <input v-model="createHubForm.announce" type="checkbox" class="size-4" />
- {{ $t("relay_chat.host_announce_periodically") }}
+ <label class="setting-toggle flex items-start gap-3">
+ <Toggle id="rrc-create-announce" v-model="createHubForm.announce" />
+ <span class="min-w-0 text-sm">
+ <span class="font-medium text-sem-fg">{{
+ $t("relay_chat.host_announce_periodically")
+ }}</span>
+ </span>
</label>
+ <div v-if="createHubForm.announce" class="space-y-2">
+ <div class="flex items-center justify-between gap-2">
+ <label
+ for="rrc-create-announce-interval"
+ class="text-sm font-semibold text-sem-fg-secondary"
+ >{{ $t("relay_chat.host_announce_interval") }}</label
+ >
+ <input
+ id="rrc-create-announce-interval-input"
+ type="text"
+ inputmode="numeric"
+ autocomplete="off"
+ maxlength="5"
+ class="w-16 shrink-0 rounded-lg border border-sem-border bg-sem-canvas px-1.5 py-1 text-center text-xs font-bold text-sem-accent tabular-nums shadow-xs focus:border-sem-accent focus:outline-hidden focus:ring-1 focus:ring-sem-accent/40"
+ :value="createAnnounceIntervalMinutesShown"
+ :aria-label="$t('relay_chat.host_announce_interval')"
+ @focus="onCreateAnnounceIntervalFocus"
+ @input="onCreateAnnounceIntervalInput"
+ @blur="onCreateAnnounceIntervalBlur"
+ />
+ </div>
+ <input
+ id="rrc-create-announce-interval"
+ type="range"
+ min="0"
+ :max="announceSliderPosMax"
+ step="1"
+ :value="createAnnounceIntervalSliderPos"
+ class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-sem-surface-muted accent-sem-accent"
+ @input="onCreateAnnounceIntervalSlider"
+ />
+ <p class="text-xs text-sem-fg-muted">
+ {{
+ $t("relay_chat.host_announce_interval_hint", {
+ minutes: createAnnounceIntervalMinutes,
+ })
+ }}
+ </p>
+ </div>
<div class="flex justify-end gap-2 pt-1">
<button type="button" :class="btnSecondary" @click="showCreateHub = false">
{{ $t("common.cancel") }}
@@ -864,6 +940,79 @@
</div>
</div>
+ <!-- hosted hub settings dialog -->
+ <div v-if="showHostHubSettings" :class="RELAY_HOST_MODAL_OVERLAY" @click.self="showHostHubSettings = false">
+ <div :class="RELAY_HOST_MODAL_PANEL_COMPACT" @click.stop>
+ <h2 class="mb-4 text-lg font-semibold text-sem-fg">{{ $t("relay_chat.host_hub_settings") }}</h2>
+ <form class="space-y-4" @submit.prevent="saveHostHubSettings">
+ <div class="space-y-1.5">
+ <label class="block text-sm font-semibold text-sem-fg-secondary">{{
+ $t("relay_chat.hub_name")
+ }}</label>
+ <input
+ v-model="hostHubSettingsForm.name"
+ type="text"
+ :placeholder="$t('relay_chat.hub_name_placeholder')"
+ class="input-field"
+ />
+ </div>
+ <label class="setting-toggle flex items-start gap-3">
+ <Toggle id="rrc-host-announce" v-model="hostHubSettingsForm.announce" />
+ <span class="min-w-0 text-sm">
+ <span class="font-medium text-sem-fg">{{
+ $t("relay_chat.host_announce_periodically")
+ }}</span>
+ </span>
+ </label>
+ <div v-if="hostHubSettingsForm.announce" class="space-y-2">
+ <div class="flex items-center justify-between gap-2">
+ <label
+ for="rrc-host-announce-interval"
+ class="text-sm font-semibold text-sem-fg-secondary"
+ >{{ $t("relay_chat.host_announce_interval") }}</label
+ >
+ <input
+ id="rrc-host-announce-interval-input"
+ type="text"
+ inputmode="numeric"
+ autocomplete="off"
+ maxlength="5"
+ class="w-16 shrink-0 rounded-lg border border-sem-border bg-sem-canvas px-1.5 py-1 text-center text-xs font-bold text-sem-accent tabular-nums shadow-xs focus:border-sem-accent focus:outline-hidden focus:ring-1 focus:ring-sem-accent/40"
+ :value="hostAnnounceIntervalMinutesShown"
+ :aria-label="$t('relay_chat.host_announce_interval')"
+ @focus="onHostAnnounceIntervalFocus"
+ @input="onHostAnnounceIntervalInput"
+ @blur="onHostAnnounceIntervalBlur"
+ />
+ </div>
+ <input
+ id="rrc-host-announce-interval"
+ type="range"
+ min="0"
+ :max="announceSliderPosMax"
+ step="1"
+ :value="hostAnnounceIntervalSliderPos"
+ class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-sem-surface-muted accent-sem-accent"
+ @input="onHostAnnounceIntervalSlider"
+ />
+ <p class="text-xs text-sem-fg-muted">
+ {{
+ $t("relay_chat.host_announce_interval_hint", {
+ minutes: hostAnnounceIntervalMinutes,
+ })
+ }}
+ </p>
+ </div>
+ <div class="flex justify-end gap-2 pt-1">
+ <button type="button" :class="btnSecondary" @click="showHostHubSettings = false">
+ {{ $t("common.cancel") }}
+ </button>
+ <button type="submit" :class="btnPrimary">{{ $t("relay_chat.save") }}</button>
+ </div>
+ </form>
+ </div>
+ </div>
+
<!-- add hub dialog -->
<div
v-if="showAddHub"
@@ -1115,6 +1264,11 @@ import { MIN_VIRTUAL_RELAY_ENTRIES } from "./relayMessageListVirtual.js";
import { loadRelayLayout, saveRelayLayout } from "../../js/relayLayoutStore.js";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../../js/browserLayoutStore.js";
import { RELAY_HOST_MODAL_OVERLAY, RELAY_HOST_MODAL_PANEL_COMPACT } from "../../js/relayHostModalClasses.js";
+import {
+ ANNOUNCE_SLIDER_POS_MAX,
+ announceMinutesToSliderPos,
+ announceSliderPosToMinutes,
+} from "../../js/announceIntervalSliderMap.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import MdiIconPickerModal from "../MdiIconPickerModal.vue";
import RelayHostModerationPage from "./RelayHostModerationPage.vue";
@@ -1142,6 +1296,25 @@ const NAME_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b
const RELAY_MESSAGES_INITIAL_PAGE_SIZE = 150;
const RELAY_MESSAGES_PREVIOUS_PAGE_SIZE = 100;
const LOAD_PREVIOUS_SCROLL_EDGE_PX = 200;
+const DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900;
+const ANNOUNCE_INTERVAL_MIN_MINUTES = 1;
+const ANNOUNCE_INTERVAL_MAX_MINUTES = 1440;
+
+function clampAnnounceIntervalMinutes(value) {
+ const n = Number.parseInt(String(value ?? ""), 10);
+ if (!Number.isFinite(n)) {
+ return Math.round(DEFAULT_ANNOUNCE_INTERVAL_SECONDS / 60);
+ }
+ return Math.max(ANNOUNCE_INTERVAL_MIN_MINUTES, Math.min(ANNOUNCE_INTERVAL_MAX_MINUTES, n));
+}
+
+function secondsToAnnounceMinutes(seconds) {
+ const s = Number(seconds);
+ if (!Number.isFinite(s) || s <= 0) {
+ return Math.round(DEFAULT_ANNOUNCE_INTERVAL_SECONDS / 60);
+ }
+ return clampAnnounceIntervalMinutes(Math.round(s / 60));
+}
export default {
name: "RelayChatPage",
@@ -1186,16 +1359,32 @@ export default {
discoveryLoading: false,
serverHubs: [],
roomForms: {},
+ announceSliderPosMax: ANNOUNCE_SLIDER_POS_MAX,
showCreateHub: false,
createHubForm: {
name: "",
greeting: "",
announce: true,
+ announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ },
+ createAnnounceIntervalDraft: null,
+ showHostHubSettings: false,
+ hostHubSettingsId: null,
+ hostHubSettingsForm: {
+ name: "",
+ announce: true,
+ announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
},
+ hostAnnounceIntervalDraft: null,
hubs: [],
selectedHubHash: null,
selectedRoom: null,
expandedHubs: {},
+ availableRoomsExpanded: {},
+ hostUptimeTick: 0,
+ hostUptimeAnchorMs: 0,
+ hostUptimeTimer: null,
+ expandedPresenceGroups: {},
relaySidebarCollapsed: loadFeatureSidebarCollapsed("relayChat") ?? false,
smUp: false,
smMq: null,
@@ -1286,6 +1475,30 @@ export default {
const status = this.settingsHub?.status ?? 0;
return this.statusIconColor(status);
},
+ createAnnounceIntervalMinutes() {
+ return secondsToAnnounceMinutes(this.createHubForm.announce_interval_seconds);
+ },
+ createAnnounceIntervalSliderPos() {
+ return announceMinutesToSliderPos(this.createAnnounceIntervalMinutes);
+ },
+ createAnnounceIntervalMinutesShown() {
+ if (this.createAnnounceIntervalDraft != null) {
+ return this.createAnnounceIntervalDraft;
+ }
+ return String(this.createAnnounceIntervalMinutes);
+ },
+ hostAnnounceIntervalMinutes() {
+ return secondsToAnnounceMinutes(this.hostHubSettingsForm.announce_interval_seconds);
+ },
+ hostAnnounceIntervalSliderPos() {
+ return announceMinutesToSliderPos(this.hostAnnounceIntervalMinutes);
+ },
+ hostAnnounceIntervalMinutesShown() {
+ if (this.hostAnnounceIntervalDraft != null) {
+ return this.hostAnnounceIntervalDraft;
+ }
+ return String(this.hostAnnounceIntervalMinutes);
+ },
messageTimeline() {
return buildRelayMessageTimeline(this.messages);
},
@@ -1354,6 +1567,12 @@ export default {
this.smMq = window.matchMedia("(min-width: 640px)");
this.smUp = this.smMq.matches;
this.smMq.addEventListener("change", this.onSmMqChange);
+ this.hostUptimeAnchorMs = Date.now();
+ this.hostUptimeTimer = window.setInterval(() => {
+ if (this.view === "host" && this.serverHubs.some((h) => h.running)) {
+ this.hostUptimeTick += 1;
+ }
+ }, 1000);
this.fetchHubs().then(() => {
this.restoreRelayLayout();
this.applyPopoutRoute();
@@ -1369,6 +1588,10 @@ export default {
if (this.discoverySearchTimer) {
clearTimeout(this.discoverySearchTimer);
}
+ if (this.hostUptimeTimer) {
+ clearInterval(this.hostUptimeTimer);
+ this.hostUptimeTimer = null;
+ }
if (this.smMq) {
this.smMq.removeEventListener("change", this.onSmMqChange);
}
@@ -1395,6 +1618,7 @@ export default {
selectedHubHash: this.selectedHubHash,
selectedRoom: this.selectedRoom,
expandedHubs: { ...this.expandedHubs },
+ availableRoomsExpanded: { ...this.availableRoomsExpanded },
relaySidebarCollapsed: this.relaySidebarCollapsed,
});
},
@@ -1415,6 +1639,9 @@ export default {
if (saved.expandedHubs && typeof saved.expandedHubs === "object") {
this.expandedHubs = { ...saved.expandedHubs };
}
+ if (saved.availableRoomsExpanded && typeof saved.availableRoomsExpanded === "object") {
+ this.availableRoomsExpanded = { ...saved.availableRoomsExpanded };
+ }
if (saved.selectedHubHash && this.hubs.some((h) => h.hub_hash === saved.selectedHubHash)) {
this.selectedHubHash = saved.selectedHubHash;
this.expandedHubs[saved.selectedHubHash] = true;
@@ -1459,6 +1686,145 @@ export default {
this.expandedHubs[hubHash] = !this.expandedHubs[hubHash];
this.persistRelayLayout();
},
+ isAvailableRoomsExpanded(hubHash) {
+ return this.availableRoomsExpanded[hubHash] !== false;
+ },
+ toggleAvailableRooms(hubHash) {
+ this.availableRoomsExpanded[hubHash] = !this.isAvailableRoomsExpanded(hubHash);
+ this.persistRelayLayout();
+ },
+ hostedHubUptimeSeconds(hub) {
+ void this.hostUptimeTick;
+ if (!hub?.running) {
+ return 0;
+ }
+ const base = Number(hub.uptime_seconds);
+ if (!Number.isFinite(base) || base < 0) {
+ return 0;
+ }
+ if (!this.hostUptimeAnchorMs) {
+ return Math.floor(base);
+ }
+ return Math.floor(base + (Date.now() - this.hostUptimeAnchorMs) / 1000);
+ },
+ formatUptime(seconds) {
+ if (seconds == null || seconds < 0) {
+ return "—";
+ }
+ let s = Math.floor(seconds);
+ if (s < 60) {
+ return `${s}s`;
+ }
+ if (s < 3600) {
+ return `${Math.floor(s / 60)}m`;
+ }
+ if (s < 86400) {
+ return `${Math.floor(s / 3600)}h`;
+ }
+ if (s < 30 * 86400) {
+ return `${Math.floor(s / 86400)}d`;
+ }
+ const yearSec = 365 * 86400;
+ const monthSec = 30 * 86400;
+ const years = Math.floor(s / yearSec);
+ s -= years * yearSec;
+ const months = Math.floor(s / monthSec);
+ s -= months * monthSec;
+ const days = Math.floor(s / 86400);
+ const parts = [];
+ if (years) {
+ parts.push(`${years}y`);
+ }
+ if (months) {
+ parts.push(`${months}mo`);
+ }
+ if (days) {
+ parts.push(`${days}d`);
+ }
+ return parts.length ? parts.join(" ") : "0d";
+ },
+ onCreateAnnounceIntervalSlider(event) {
+ const minutes = announceSliderPosToMinutes(event?.target?.value);
+ this.createHubForm.announce_interval_seconds = minutes * 60;
+ this.createAnnounceIntervalDraft = null;
+ },
+ onCreateAnnounceIntervalFocus() {
+ this.createAnnounceIntervalDraft = String(this.createAnnounceIntervalMinutes);
+ },
+ onCreateAnnounceIntervalInput(event) {
+ const raw = String(event?.target?.value ?? "").replace(/\D/g, "");
+ this.createAnnounceIntervalDraft = raw;
+ if (raw === "") {
+ return;
+ }
+ const minutes = Number.parseInt(raw, 10);
+ if (Number.isFinite(minutes)) {
+ this.createHubForm.announce_interval_seconds = clampAnnounceIntervalMinutes(minutes) * 60;
+ }
+ },
+ onCreateAnnounceIntervalBlur() {
+ const minutes = clampAnnounceIntervalMinutes(
+ this.createAnnounceIntervalDraft || this.createAnnounceIntervalMinutes
+ );
+ this.createHubForm.announce_interval_seconds = minutes * 60;
+ this.createAnnounceIntervalDraft = null;
+ },
+ onHostAnnounceIntervalSlider(event) {
+ const minutes = announceSliderPosToMinutes(event?.target?.value);
+ this.hostHubSettingsForm.announce_interval_seconds = minutes * 60;
+ this.hostAnnounceIntervalDraft = null;
+ },
+ onHostAnnounceIntervalFocus() {
+ this.hostAnnounceIntervalDraft = String(this.hostAnnounceIntervalMinutes);
+ },
+ onHostAnnounceIntervalInput(event) {
+ const raw = String(event?.target?.value ?? "").replace(/\D/g, "");
+ this.hostAnnounceIntervalDraft = raw;
+ if (raw === "") {
+ return;
+ }
+ const minutes = Number.parseInt(raw, 10);
+ if (Number.isFinite(minutes)) {
+ this.hostHubSettingsForm.announce_interval_seconds = clampAnnounceIntervalMinutes(minutes) * 60;
+ }
+ },
+ onHostAnnounceIntervalBlur() {
+ const minutes = clampAnnounceIntervalMinutes(
+ this.hostAnnounceIntervalDraft || this.hostAnnounceIntervalMinutes
+ );
+ this.hostHubSettingsForm.announce_interval_seconds = minutes * 60;
+ this.hostAnnounceIntervalDraft = null;
+ },
+ openHostHubSettings(hub) {
+ this.hostHubSettingsId = hub.id;
+ this.hostHubSettingsForm = {
+ name: hub.name || "",
+ announce: hub.announce !== false,
+ announce_interval_seconds:
+ hub.announce_interval_seconds > 0
+ ? hub.announce_interval_seconds
+ : DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ };
+ this.hostAnnounceIntervalDraft = null;
+ this.showHostHubSettings = true;
+ },
+ async saveHostHubSettings() {
+ if (!this.hostHubSettingsId) {
+ return;
+ }
+ try {
+ await window.api.patch(`/api/v1/rrc/servers/${this.hostHubSettingsId}`, {
+ name: this.hostHubSettingsForm.name.trim() || undefined,
+ announce: this.hostHubSettingsForm.announce,
+ announce_interval_seconds: this.hostHubSettingsForm.announce_interval_seconds,
+ });
+ this.showHostHubSettings = false;
+ ToastUtils.success(this.$t("relay_chat.settings_saved"));
+ await this.fetchServers();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("relay_chat.action_failed"));
+ }
+ },
statusLabel(status) {
switch (status) {
case 1:
@@ -1834,9 +2200,42 @@ export default {
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}-${index}`;
}
+ if (entry.type === "presenceGroup") {
+ return `presence-${entry.id}-${index}`;
+ }
const msgKey = this.messageKey(entry.msg);
return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
},
+ isPresenceGroupExpanded(groupId) {
+ return !!this.expandedPresenceGroups[groupId];
+ },
+ togglePresenceGroup(groupId) {
+ if (!groupId) {
+ return;
+ }
+ this.expandedPresenceGroups[groupId] = !this.expandedPresenceGroups[groupId];
+ },
+ formatPresenceGroupSummary(entry) {
+ const joined = Number(entry?.joinedCount) || 0;
+ const left = Number(entry?.leftCount) || 0;
+ const connection = Number(entry?.connectionCount) || 0;
+ const parts = [];
+ if (joined > 0) {
+ parts.push(this.$t("relay_chat.presence_joined", { count: joined }));
+ }
+ if (left > 0) {
+ parts.push(this.$t("relay_chat.presence_left", { count: left }));
+ }
+ if (connection > 0) {
+ parts.push(this.$t("relay_chat.presence_connection", { count: connection }));
+ }
+ if (parts.length === 0) {
+ return this.$t("relay_chat.presence_events", {
+ count: Array.isArray(entry?.messages) ? entry.messages.length : 0,
+ });
+ }
+ return parts.join(" · ");
+ },
formatDateDividerLabel(dayKey) {
if (!dayKey || typeof dayKey !== "string") {
return "";
@@ -1955,6 +2354,7 @@ export default {
this.selectedRoom = room;
this.expandedHubs[hubHash] = true;
this.hasMorePrevious = false;
+ this.expandedPresenceGroups = {};
// Clear before fetch so only websocket arrivals during the request are merged back.
this.messages = [];
this.members = [];
@@ -2364,12 +2764,20 @@ export default {
}
}
this.serverHubs = hubs;
+ this.hostUptimeAnchorMs = Date.now();
+ this.hostUptimeTick = 0;
} catch {
// relay chat hosting may be unavailable for this identity
}
},
openCreateHub() {
- this.createHubForm = { name: "", greeting: "", announce: true };
+ this.createHubForm = {
+ name: "",
+ greeting: "",
+ announce: true,
+ announce_interval_seconds: DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ };
+ this.createAnnounceIntervalDraft = null;
this.showCreateHub = true;
},
async createServerHub() {
@@ -2378,6 +2786,7 @@ export default {
name: this.createHubForm.name.trim() || undefined,
greeting: this.createHubForm.greeting.trim() || undefined,
announce: this.createHubForm.announce,
+ announce_interval_seconds: this.createHubForm.announce_interval_seconds,
});
this.showCreateHub = false;
ToastUtils.success(this.$t("relay_chat.host_hub_created"));
diff --git a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
index 92e837ea..696c8603 100644
--- a/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
+++ b/meshchatx/src/frontend/components/relay/RelayMessageEntry.vue
@@ -15,6 +15,30 @@
</span>
<span class="h-px w-10 shrink-0 bg-sem-border sm:w-14" aria-hidden="true" />
</div>
+ <div v-else-if="entry.type === 'presenceGroup'" class="px-2 py-1">
+ <button
+ type="button"
+ class="mx-auto flex max-w-full items-center gap-1 rounded-md px-2 py-0.5 text-xs italic text-sem-fg-muted transition-colors hover:bg-sem-surface/50 hover:text-sem-fg"
+ :aria-expanded="expanded"
+ @click="page.togglePresenceGroup(entry.id)"
+ >
+ <MaterialDesignIcon
+ :icon-name="expanded ? 'chevron-down' : 'chevron-right'"
+ class="size-3.5 shrink-0 opacity-70"
+ />
+ <span class="truncate">{{ page.formatPresenceGroupSummary(entry) }}</span>
+ </button>
+ <div v-if="expanded" class="mt-1 space-y-0.5">
+ <div
+ v-for="(msg, idx) in entry.messages"
+ :key="page.messageKey(msg) || idx"
+ class="py-0.5 text-center text-xs italic text-sem-fg-muted"
+ :data-msg-key="page.messageKey(msg)"
+ >
+ {{ msg.text }}
+ </div>
+ </div>
+ </div>
<div
v-else-if="isSystemLike"
class="py-0.5 text-center text-xs italic"
@@ -47,6 +71,7 @@
<script setup>
import { computed } from "vue";
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
const props = defineProps({
entry: {
@@ -63,4 +88,11 @@ const isSystemLike = computed(() => {
const kind = props.entry?.msg?.kind;
return kind === "system" || kind === "notice" || kind === "error";
});
+
+const expanded = computed(() => {
+ if (props.entry?.type !== "presenceGroup") {
+ return false;
+ }
+ return props.page.isPresenceGroupExpanded(props.entry.id);
+});
</script>
diff --git a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
index ca7aa230..31d4a6d5 100644
--- a/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
+++ b/meshchatx/src/frontend/components/relay/RelayMessageListVirtual.vue
@@ -55,6 +55,9 @@ function entryKey(entry, index) {
if (entry.type === "dateDivider") {
return `date-${entry.dayKey}-${index}`;
}
+ if (entry.type === "presenceGroup") {
+ return `presence-${entry.id}-${index}`;
+ }
const msgKey = props.page.messageKey(entry.msg);
return msgKey ? `${msgKey}-${index}` : `idx-${index}`;
}
diff --git a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
index cf71f7ae..387e51fb 100644
--- a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
+++ b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js
@@ -12,6 +12,9 @@ export function estimateRelayEntryHeight(entry) {
if (entry.type === "dateDivider") {
return 44;
}
+ if (entry.type === "presenceGroup") {
+ return 28;
+ }
const text = typeof entry.msg?.text === "string" ? entry.msg.text : "";
let height = 32;
if (text) {
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index dca03f1f..baaad073 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3275,6 +3275,7 @@ export default {
item("http_bots_status_good", "selftest.http_bots_status_good"),
item("http_security_good", "selftest.http_security_good"),
item("http_interfaces_good", "selftest.http_interfaces_good"),
+ item("http_reticulum_instance_good", "selftest.http_reticulum_instance_good"),
item("http_identities_good", "selftest.http_identities_good"),
item("http_favourites_good", "selftest.http_favourites_good"),
item("http_telephone_good", "selftest.http_telephone_good"),
@@ -3505,6 +3506,7 @@ export default {
http_bots_status_good: { ...failed },
http_security_good: { ...failed },
http_interfaces_good: { ...failed },
+ http_reticulum_instance_good: { ...failed },
http_identities_good: { ...failed },
http_favourites_good: { ...failed },
http_telephone_good: { ...failed },
diff --git a/meshchatx/src/frontend/js/announceIntervalSliderMap.js b/meshchatx/src/frontend/js/announceIntervalSliderMap.js
new file mode 100644
index 00000000..247cbd54
--- /dev/null
+++ b/meshchatx/src/frontend/js/announceIntervalSliderMap.js
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+/** High-resolution slider positions for smooth dragging (minutes 1..1440). */
+export const ANNOUNCE_SLIDER_POS_MAX = 2047;
+
+const MIN_MINUTES = 1;
+const MAX_MINUTES = 1440;
+
+/**
+ * @param {number} pos
+ * @returns {number} Announce interval in minutes (1..1440).
+ */
+export function announceSliderPosToMinutes(pos) {
+ const p = Math.max(0, Math.min(ANNOUNCE_SLIDER_POS_MAX, Math.round(Number(pos) || 0)));
+ return Math.round(MIN_MINUTES + (p / ANNOUNCE_SLIDER_POS_MAX) * (MAX_MINUTES - MIN_MINUTES));
+}
+
+/**
+ * @param {number} minutes
+ * @returns {number} Slider position 0 .. ANNOUNCE_SLIDER_POS_MAX
+ */
+export function announceMinutesToSliderPos(minutes) {
+ const m = Math.max(MIN_MINUTES, Math.min(MAX_MINUTES, Math.round(Number(minutes) || MIN_MINUTES)));
+ return Math.round(((m - MIN_MINUTES) / (MAX_MINUTES - MIN_MINUTES)) * ANNOUNCE_SLIDER_POS_MAX);
+}
diff --git a/meshchatx/src/frontend/js/relayMessageTimeline.js b/meshchatx/src/frontend/js/relayMessageTimeline.js
index 1a709aa3..07b01cae 100644
--- a/meshchatx/src/frontend/js/relayMessageTimeline.js
+++ b/meshchatx/src/frontend/js/relayMessageTimeline.js
@@ -15,7 +15,9 @@ export function relayMessageKey(msg) {
}
const src = msg.src || "";
const text = typeof msg.text === "string" ? msg.text : "";
- return `${msg.kind || "msg"}-${msg.ts || 0}-${src}-${text.length}-${text.slice(0, 24)}`;
+ // Keep keys attribute-safe: never embed raw message text (XSS-shaped payloads).
+ const textFrag = encodeURIComponent(text.slice(0, 24));
+ return `${msg.kind || "msg"}-${msg.ts || 0}-${src}-${text.length}-${textFrag}`;
}
/**
@@ -59,9 +61,77 @@ export function mergeRelayMessages(base, extras) {
return out;
}
+const CONNECTION_EVENT_TEXTS = new Set(["Connection lost", "Disconnected from hub", "Reconnected to hub"]);
+
+/**
+ * Join/leave/connection system lines produced by the RRC client.
+ * @param {object} msg
+ * @returns {boolean}
+ */
+export function isRelayPresenceSystemMessage(msg) {
+ if (!msg || msg.kind !== "system") {
+ return false;
+ }
+ const text = typeof msg.text === "string" ? msg.text.trim() : "";
+ if (!text) {
+ return false;
+ }
+ if (CONNECTION_EVENT_TEXTS.has(text)) {
+ return true;
+ }
+ if (text.endsWith(" joined") || text.endsWith(" left")) {
+ return true;
+ }
+ return /^You (?:re)?joined #/i.test(text);
+}
+
+/**
+ * @param {object} msg
+ * @returns {"joined"|"left"|"connection"}
+ */
+export function relayPresenceEventKind(msg) {
+ const text = typeof msg?.text === "string" ? msg.text.trim() : "";
+ if (CONNECTION_EVENT_TEXTS.has(text)) {
+ return "connection";
+ }
+ if (text.endsWith(" left")) {
+ return "left";
+ }
+ return "joined";
+}
+
+/**
+ * @param {object[]} presenceMessages
+ * @returns {{ type: string, id: string, messages: object[], joinedCount: number, leftCount: number, connectionCount: number }}
+ */
+function buildPresenceGroup(presenceMessages) {
+ let joinedCount = 0;
+ let leftCount = 0;
+ let connectionCount = 0;
+ for (const msg of presenceMessages) {
+ const kind = relayPresenceEventKind(msg);
+ if (kind === "left") {
+ leftCount += 1;
+ } else if (kind === "connection") {
+ connectionCount += 1;
+ } else {
+ joinedCount += 1;
+ }
+ }
+ const firstKey = relayMessageKey(presenceMessages[0]);
+ return {
+ type: "presenceGroup",
+ id: firstKey || `presence-${presenceMessages.length}`,
+ messages: presenceMessages,
+ joinedCount,
+ leftCount,
+ connectionCount,
+ };
+}
+
/**
* @param {object[]} messages
- * @returns {{ type: string, dayKey?: string, msg?: object }[]}
+ * @returns {{ type: string, dayKey?: string, msg?: object, id?: string, messages?: object[], joinedCount?: number, leftCount?: number, connectionCount?: number }[]}
*/
export function buildRelayMessageTimeline(messages) {
if (!Array.isArray(messages) || messages.length === 0) {
@@ -69,6 +139,21 @@ export function buildRelayMessageTimeline(messages) {
}
const out = [];
let prevDayKey = null;
+ /** @type {object[]} */
+ let presenceBuffer = [];
+
+ const flushPresence = () => {
+ if (presenceBuffer.length === 0) {
+ return;
+ }
+ if (presenceBuffer.length === 1) {
+ out.push({ type: "message", msg: presenceBuffer[0] });
+ } else {
+ out.push(buildPresenceGroup(presenceBuffer));
+ }
+ presenceBuffer = [];
+ };
+
for (const msg of messages) {
let dayKey = null;
if (msg?.ts != null) {
@@ -79,10 +164,17 @@ export function buildRelayMessageTimeline(messages) {
}
}
if (dayKey && dayKey !== prevDayKey) {
+ flushPresence();
out.push({ type: "dateDivider", dayKey });
prevDayKey = dayKey;
}
+ if (isRelayPresenceSystemMessage(msg)) {
+ presenceBuffer.push(msg);
+ continue;
+ }
+ flushPresence();
out.push({ type: "message", msg });
}
+ flushPresence();
return out;
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index c1f8584e..507a85c8 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -763,7 +763,8 @@
"failed": "Fehlgeschlagen",
"status_label": "Status",
"reason_label": "Grund",
- "checks_completed": "Alle Prüfungen erfolgreich bestanden."
+ "checks_completed": "Alle Prüfungen erfolgreich bestanden.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Wartung & Daten",
@@ -3203,7 +3204,7 @@
"discovery_title": "Entdeckte Hubs",
"discovery_subtitle": "Relay-Chat-Hubs, die im Netzwerk angekündigt wurden.",
"discovery_empty": "Noch keine Hubs entdeckt. Sie erscheinen hier, sobald sie sich ankündigen.",
- "discovery_search": "Hubs suchen",
+ "discovery_search": "{count} Hubs durchsuchen",
"discovery_add": "Hinzufügen",
"discovery_added": "Hub aus Entdeckung hinzugefügt",
"discovery_open": "Öffnen",
@@ -3272,7 +3273,15 @@
"search_messages": "Suchen",
"search_no_results": "Keine Nachrichten entsprechen der Suche",
"popout_channel": "In neuem Fenster öffnen",
- "new_message_toast": "Neue Nachricht in #{room}"
+ "new_message_toast": "Neue Nachricht in #{room}",
+ "host_users": "Benutzer",
+ "host_announce_interval": "Announce-Intervall (Minuten)",
+ "host_announce_interval_hint": "Alle {minutes} Minuten",
+ "host_hub_settings": "Hub-Einstellungen",
+ "presence_joined": "{count} beigetreten",
+ "presence_left": "{count} verlassen",
+ "presence_events": "{count} Beitritts-/Austrittsereignisse",
+ "presence_connection": "{count} Verbindungsereignisse"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 51f7b385..bb0f8646 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -725,7 +725,7 @@
},
"selftest": {
"title": "System Self-Test",
- "description": "Run diagnostic checks for the network stack, database, identity, imports, storage, LXMF, subprocess/run-module, SQLite, identity files, loopback TCP, unicode paths, RNode helpers, bot launcher argv, HTTP status/config/health/auth/bots/security/interfaces/identities/favourites/telephone APIs, WebSocket, and bot lifecycle.",
+ "description": "Run diagnostic checks for the network stack, database, identity, imports, storage, LXMF, subprocess/run-module, SQLite, identity files, loopback TCP, unicode paths, RNode helpers, bot launcher argv, HTTP status/config/health/auth/bots/security/interfaces/reticulum-instance/identities/favourites/telephone APIs, WebSocket, and bot lifecycle.",
"run_test_btn": "Run Diagnostics",
"running": "Running Diagnostics...",
"stack_up": "Network Stack",
@@ -754,6 +754,7 @@
"http_bots_status_good": "HTTP Bots Status",
"http_security_good": "HTTP Server Security",
"http_interfaces_good": "HTTP RNS Interfaces",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings",
"http_identities_good": "HTTP Identities",
"http_favourites_good": "HTTP Favourites",
"http_telephone_good": "HTTP Telephone Status",
@@ -2602,7 +2603,7 @@
"discovery_title": "Discovered Hubs",
"discovery_subtitle": "Relay Chat hubs heard announcing on the network.",
"discovery_empty": "No hubs discovered yet. They appear here as they announce.",
- "discovery_search": "Search hubs",
+ "discovery_search": "Search {count} hubs",
"discovery_add": "Add",
"discovery_added": "Hub added from discovery",
"discovery_open": "Open",
@@ -2631,7 +2632,15 @@
"members_search_placeholder": "Search members...",
"members_search_no_results": "No members match your search.",
"popout_channel": "Open in new window",
- "new_message_toast": "New message in #{room}"
+ "new_message_toast": "New message in #{room}",
+ "host_users": "users",
+ "host_announce_interval": "Announce interval (minutes)",
+ "host_announce_interval_hint": "Every {minutes} minutes",
+ "host_hub_settings": "Hub settings",
+ "presence_joined": "{count} joined",
+ "presence_left": "{count} left",
+ "presence_events": "{count} join/leave events",
+ "presence_connection": "{count} connection events"
},
"rncp": {
"file_transfer": "File Transfer",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 096b1fc7..fe18064d 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -763,7 +763,8 @@
"failed": "Fallido",
"status_label": "Estado",
"reason_label": "Motivo",
- "checks_completed": "Todas las comprobaciones se completaron con éxito."
+ "checks_completed": "Todas las comprobaciones se completaron con éxito.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Datos de mantenimiento",
@@ -3203,7 +3204,7 @@
"discovery_title": "Hubs descubiertos",
"discovery_subtitle": "Hubs de Relay Chat anunciados en la red.",
"discovery_empty": "Aún no se han descubierto hubs. Aparecerán aquí cuando se anuncien.",
- "discovery_search": "Buscar hubs",
+ "discovery_search": "Buscar {count} hubs",
"discovery_add": "Añadir",
"discovery_added": "Hub añadido desde el descubrimiento",
"discovery_open": "Abrir",
@@ -3272,7 +3273,15 @@
"search_messages": "Buscar",
"search_no_results": "Ningún mensaje coincide con la búsqueda",
"popout_channel": "Abrir en una ventana nueva",
- "new_message_toast": "Nuevo mensaje en #{room}"
+ "new_message_toast": "Nuevo mensaje en #{room}",
+ "host_users": "usuarios",
+ "host_announce_interval": "Intervalo de anuncio (minutos)",
+ "host_announce_interval_hint": "Cada {minutes} minutos",
+ "host_hub_settings": "Ajustes del hub",
+ "presence_joined": "{count} se unieron",
+ "presence_left": "{count} salieron",
+ "presence_events": "{count} eventos de entrada/salida",
+ "presence_connection": "{count} eventos de conexión"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 105bb605..9d3ec933 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -763,7 +763,8 @@
"failed": "Epäonnistunut",
"status_label": "Tila",
"reason_label": "Syy",
- "checks_completed": "Kaikki tarkistukset suoritettiin onnistuneesti."
+ "checks_completed": "Kaikki tarkistukset suoritettiin onnistuneesti.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Ylläpito ja tiedot",
@@ -2602,7 +2603,7 @@
"discovery_title": "Discovered Hubs",
"discovery_subtitle": "Relay Chat hubs heard announcing on the network.",
"discovery_empty": "No hubs discovered yet. They appear here as they announce.",
- "discovery_search": "Search hubs",
+ "discovery_search": "Hae {count} hubia",
"discovery_add": "Lisää",
"discovery_added": "Hub added from discovery",
"discovery_open": "Avaa",
@@ -2631,7 +2632,15 @@
"members_search_placeholder": "Search members...",
"members_search_no_results": "No members match your search.",
"popout_channel": "Open in new window",
- "new_message_toast": "New message in #{room}"
+ "new_message_toast": "New message in #{room}",
+ "host_users": "käyttäjää",
+ "host_announce_interval": "Ilmoitusväli (minuuttia)",
+ "host_announce_interval_hint": "Joka {minutes}. minuutti",
+ "host_hub_settings": "Hubin asetukset",
+ "presence_joined": "{count} liittyi",
+ "presence_left": "{count} lähti",
+ "presence_events": "{count} liittymis-/lähtötapahtumaa",
+ "presence_connection": "{count} yhteystapahtumaa"
},
"rncp": {
"file_transfer": "File Transfer",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index b12a0de6..6586d453 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -763,7 +763,8 @@
"failed": "Échoué",
"status_label": "Statut",
"reason_label": "Raison",
- "checks_completed": "Tous les contrôles ont réussi."
+ "checks_completed": "Tous les contrôles ont réussi.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Maintenance et données",
@@ -3203,7 +3204,7 @@
"discovery_title": "Hubs découverts",
"discovery_subtitle": "Hubs Relay Chat annoncés sur le réseau.",
"discovery_empty": "Aucun hub découvert pour l'instant. Ils apparaissent ici lorsqu'ils s'annoncent.",
- "discovery_search": "Rechercher des hubs",
+ "discovery_search": "Rechercher {count} hubs",
"discovery_add": "Ajouter",
"discovery_added": "Hub ajouté depuis la découverte",
"discovery_open": "Ouvrir",
@@ -3272,7 +3273,15 @@
"search_messages": "Rechercher",
"search_no_results": "Aucun message ne correspond à la recherche",
"popout_channel": "Ouvrir dans une nouvelle fenêtre",
- "new_message_toast": "Nouveau message dans #{room}"
+ "new_message_toast": "Nouveau message dans #{room}",
+ "host_users": "utilisateurs",
+ "host_announce_interval": "Intervalle d'annonce (minutes)",
+ "host_announce_interval_hint": "Toutes les {minutes} minutes",
+ "host_hub_settings": "Paramètres du hub",
+ "presence_joined": "{count} ont rejoint",
+ "presence_left": "{count} sont partis",
+ "presence_events": "{count} événements entrée/sortie",
+ "presence_connection": "{count} événements de connexion"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index d60c1a25..3f3ad21b 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -763,7 +763,8 @@
"failed": "Fallito",
"status_label": "Stato",
"reason_label": "Motivo",
- "checks_completed": "Tutti i controlli sono stati superati con successo."
+ "checks_completed": "Tutti i controlli sono stati superati con successo.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Manutenzione e Dati",
@@ -3203,7 +3204,7 @@
"discovery_title": "Hub scoperti",
"discovery_subtitle": "Hub Relay Chat annunciati sulla rete.",
"discovery_empty": "Nessun hub ancora scoperto. Appariranno qui quando si annunciano.",
- "discovery_search": "Cerca hub",
+ "discovery_search": "Cerca {count} hub",
"discovery_add": "Aggiungi",
"discovery_added": "Hub aggiunto dalla scoperta",
"discovery_open": "Apri",
@@ -3272,7 +3273,15 @@
"search_messages": "Cerca",
"search_no_results": "Nessun messaggio corrisponde alla ricerca",
"popout_channel": "Apri in una nuova finestra",
- "new_message_toast": "Nuovo messaggio in #{room}"
+ "new_message_toast": "Nuovo messaggio in #{room}",
+ "host_users": "utenti",
+ "host_announce_interval": "Intervallo annuncio (minuti)",
+ "host_announce_interval_hint": "Ogni {minutes} minuti",
+ "host_hub_settings": "Impostazioni hub",
+ "presence_joined": "{count} entrati",
+ "presence_left": "{count} usciti",
+ "presence_events": "{count} eventi entrata/uscita",
+ "presence_connection": "{count} eventi di connessione"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 9eca8cb3..67ab8656 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -763,7 +763,8 @@
"failed": "Mislukt",
"status_label": "Status",
"reason_label": "Reden",
- "checks_completed": "Alle controles zijn succesvol geslaagd."
+ "checks_completed": "Alle controles zijn succesvol geslaagd.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Onderhoud & gegevens",
@@ -3203,7 +3204,7 @@
"discovery_title": "Ontdekte hubs",
"discovery_subtitle": "Relay Chat-hubs die op het netwerk aankondigen.",
"discovery_empty": "Nog geen hubs ontdekt. Ze verschijnen hier zodra ze aankondigen.",
- "discovery_search": "Hubs zoeken",
+ "discovery_search": "{count} hubs zoeken",
"discovery_add": "Toevoegen",
"discovery_added": "Hub toegevoegd via ontdekking",
"discovery_open": "Openen",
@@ -3272,7 +3273,15 @@
"search_messages": "Zoeken",
"search_no_results": "Geen berichten komen overeen met je zoekopdracht",
"popout_channel": "Openen in nieuw venster",
- "new_message_toast": "Nieuw bericht in #{room}"
+ "new_message_toast": "Nieuw bericht in #{room}",
+ "host_users": "gebruikers",
+ "host_announce_interval": "Aankondigingsinterval (minuten)",
+ "host_announce_interval_hint": "Elke {minutes} minuten",
+ "host_hub_settings": "Hub-instellingen",
+ "presence_joined": "{count} toegetreden",
+ "presence_left": "{count} vertrokken",
+ "presence_events": "{count} join/leave-gebeurtenissen",
+ "presence_connection": "{count} verbindingsgebeurtenissen"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index d2510226..52aae246 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -763,7 +763,8 @@
"failed": "Ошибка",
"status_label": "Статус",
"reason_label": "Причина",
- "checks_completed": "Все проверки выполнены успешно."
+ "checks_completed": "Все проверки выполнены успешно.",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "Обслуживание и данные",
@@ -3203,7 +3204,7 @@
"discovery_title": "Обнаруженные хабы",
"discovery_subtitle": "Хабы Relay Chat, объявленные в сети.",
"discovery_empty": "Пока не обнаружено ни одного хаба. Они появятся здесь после объявления.",
- "discovery_search": "Поиск хабов",
+ "discovery_search": "Поиск среди {count} хабов",
"discovery_add": "Добавить",
"discovery_added": "Хаб добавлен из обзора",
"discovery_open": "Открыть",
@@ -3272,7 +3273,15 @@
"search_messages": "Поиск",
"search_no_results": "Сообщения по запросу не найдены",
"popout_channel": "Открыть в новом окне",
- "new_message_toast": "Новое сообщение в #{room}"
+ "new_message_toast": "Новое сообщение в #{room}",
+ "host_users": "пользователей",
+ "host_announce_interval": "Интервал анонса (минуты)",
+ "host_announce_interval_hint": "Каждые {minutes} мин.",
+ "host_hub_settings": "Настройки хаба",
+ "presence_joined": "{count} вошли",
+ "presence_left": "{count} вышли",
+ "presence_events": "{count} событий входа/выхода",
+ "presence_connection": "{count} событий соединения"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index c1f894dc..6543bbd5 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -763,7 +763,8 @@
"failed": "已失败",
"status_label": "状态",
"reason_label": "原因",
- "checks_completed": "所有检查已成功通过。"
+ "checks_completed": "所有检查已成功通过。",
+ "http_reticulum_instance_good": "HTTP RNS Instance Settings"
},
"maintenance": {
"title": "数据维护",
@@ -3203,7 +3204,7 @@
"discovery_title": "已发现的中枢",
"discovery_subtitle": "在网络上通告的 Relay Chat 中枢。",
"discovery_empty": "尚未发现中枢。它们在通告后会出现在此处。",
- "discovery_search": "搜索中枢",
+ "discovery_search": "搜索 {count} 个枢纽",
"discovery_add": "添加",
"discovery_added": "已从发现添加中枢",
"discovery_open": "打开",
@@ -3272,7 +3273,15 @@
"search_messages": "搜索",
"search_no_results": "没有匹配搜索的消息",
"popout_channel": "在新窗口中打开",
- "new_message_toast": "#{room} 中有新消息"
+ "new_message_toast": "#{room} 中有新消息",
+ "host_users": "用户",
+ "host_announce_interval": "通告间隔(分钟)",
+ "host_announce_interval_hint": "每 {minutes} 分钟",
+ "host_hub_settings": "枢纽设置",
+ "presence_joined": "{count} 人加入",
+ "presence_left": "{count} 人离开",
+ "presence_events": "{count} 条加入/离开事件",
+ "presence_connection": "{count} 条连接事件"
},
"android_storage": {
"setup_title": "Choose where MeshChatX stores data",
diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py
index 2820128e..700492b1 100644
--- a/tests/backend/api_json_contract_schemas.py
+++ b/tests/backend/api_json_contract_schemas.py
@@ -226,6 +226,7 @@ SELF_TEST_SCHEMA: dict = {
"http_bots_status_good",
"http_security_good",
"http_interfaces_good",
+ "http_reticulum_instance_good",
"http_identities_good",
"http_favourites_good",
"http_telephone_good",
@@ -259,6 +260,7 @@ SELF_TEST_SCHEMA: dict = {
"http_bots_status_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_security_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_interfaces_good": SELF_TEST_STATUS_ITEM_SCHEMA,
+ "http_reticulum_instance_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_identities_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_favourites_good": SELF_TEST_STATUS_ITEM_SCHEMA,
"http_telephone_good": SELF_TEST_STATUS_ITEM_SCHEMA,
diff --git a/tests/backend/test_reticulum_instance_settings.py b/tests/backend/test_reticulum_instance_settings.py
index 8ad3bf5e..74ed93a7 100644
--- a/tests/backend/test_reticulum_instance_settings.py
+++ b/tests/backend/test_reticulum_instance_settings.py
@@ -7,6 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
from meshchatx.meshchat import ReticulumMeshChat
@@ -54,6 +56,39 @@ def test_parse_rns_config_bool():
assert ReticulumMeshChat._format_rns_config_bool(False) == "No"
+@given(
+ raw=st.one_of(
+ st.booleans(),
+ st.sampled_from(
+ [
+ "Yes",
+ "No",
+ "yes",
+ "no",
+ "TRUE",
+ "false",
+ "1",
+ "0",
+ "on",
+ "off",
+ "",
+ " Yes ",
+ ]
+ ),
+ st.integers(min_value=-3, max_value=3),
+ st.none(),
+ ),
+ default=st.booleans(),
+)
+@settings(max_examples=80)
+def test_parse_rns_config_bool_fuzz(raw, default):
+ result = ReticulumMeshChat._parse_rns_config_bool(raw, default=default)
+ assert isinstance(result, bool)
+ formatted = ReticulumMeshChat._format_rns_config_bool(result)
+ assert formatted in ("Yes", "No")
+ assert ReticulumMeshChat._parse_rns_config_bool(formatted) is result
+
+
@pytest.mark.asyncio
async def test_reticulum_instance_get_and_patch(temp_dir):
config = ConfigDict(
@@ -177,3 +212,247 @@ async def test_reticulum_instance_rejects_bad_type(temp_dir):
response = await patch_handler(PatchRequest())
assert response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_reticulum_instance_rejects_bad_instance_name(temp_dir):
+ config = ConfigDict({"reticulum": {"share_instance": "Yes"}, "interfaces": {}})
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.share_instance = True
+ mock_reticulum.transport_enabled.return_value = False
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ app_instance.reload_reticulum = AsyncMock(return_value=True)
+ patch_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/instance",
+ "PATCH",
+ )
+
+ class PatchRequest:
+ @staticmethod
+ async def json():
+ return {"instance_name": "bad name"}
+
+ response = await patch_handler(PatchRequest())
+ assert response.status == 400
+ app_instance.reload_reticulum.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_reticulum_instance_empty_patch_noop(temp_dir):
+ config = ConfigDict(
+ {
+ "reticulum": {
+ "share_instance": "Yes",
+ "local_hops_delta": "No",
+ },
+ "interfaces": {},
+ },
+ )
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.share_instance = True
+ mock_reticulum.transport_enabled.return_value = False
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ app_instance.reload_reticulum = AsyncMock(return_value=True)
+ patch_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/instance",
+ "PATCH",
+ )
+
+ class PatchRequest:
+ @staticmethod
+ async def json():
+ return {}
+
+ response = await patch_handler(PatchRequest())
+ assert response.status == 200
+ assert config.write_called is False
+ app_instance.reload_reticulum.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_reticulum_instance_clears_optional_fields(temp_dir):
+ config = ConfigDict(
+ {
+ "reticulum": {
+ "share_instance": "Yes",
+ "shared_instance_type": "tcp",
+ "instance_name": "custom",
+ },
+ "interfaces": {},
+ },
+ )
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.share_instance = True
+ mock_reticulum.transport_enabled.return_value = False
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ app_instance.reload_reticulum = AsyncMock(return_value=True)
+ patch_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/instance",
+ "PATCH",
+ )
+
+ class PatchRequest:
+ @staticmethod
+ async def json():
+ return {"shared_instance_type": "", "instance_name": ""}
+
+ response = await patch_handler(PatchRequest())
+ assert response.status == 200
+ assert "shared_instance_type" not in config["reticulum"]
+ assert "instance_name" not in config["reticulum"]
+
+
+@given(
+ payload=st.fixed_dictionaries(
+ {},
+ optional={
+ "share_instance": st.one_of(
+ st.booleans(), st.sampled_from(["Yes", "No", 1, 0])
+ ),
+ "local_hops_delta": st.one_of(
+ st.booleans(), st.sampled_from(["yes", "no"])
+ ),
+ "respond_to_probes": st.booleans(),
+ "enable_remote_management": st.booleans(),
+ "shared_instance_type": st.one_of(
+ st.none(),
+ st.sampled_from(["tcp", "unix", "TCP", "Unix", "udp", "quic", ""]),
+ ),
+ "instance_name": st.one_of(
+ st.none(),
+ st.sampled_from(
+ ["default", "meshchatx", "a", "bad name", "x" * 65, ""]
+ ),
+ st.text(min_size=0, max_size=80),
+ ),
+ },
+ )
+)
+@settings(
+ max_examples=60,
+ deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+)
+@pytest.mark.asyncio
+async def test_reticulum_instance_patch_fuzz_never_500(payload, temp_dir):
+ config = ConfigDict(
+ {
+ "reticulum": {
+ "share_instance": "Yes",
+ "local_hops_delta": "No",
+ },
+ "interfaces": {},
+ },
+ )
+
+ with (
+ patch("meshchatx.meshchat.generate_ssl_certificate"),
+ patch("RNS.Reticulum") as mock_rns,
+ patch("RNS.Transport"),
+ patch("LXMF.LXMRouter"),
+ ):
+ mock_reticulum = mock_rns.return_value
+ mock_reticulum.config = config
+ mock_reticulum.configpath = "/tmp/mock_config"
+ mock_reticulum.is_connected_to_shared_instance = False
+ mock_reticulum.share_instance = True
+ mock_reticulum.transport_enabled.return_value = False
+
+ app_instance = ReticulumMeshChat(
+ identity=build_identity(),
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ app_instance.reload_reticulum = AsyncMock(return_value=True)
+ patch_handler = await find_route_handler(
+ app_instance,
+ "/api/v1/reticulum/instance",
+ "PATCH",
+ )
+
+ class PatchRequest:
+ @staticmethod
+ async def json():
+ return payload
+
+ response = await patch_handler(PatchRequest())
+ assert response.status in (200, 400)
+ body = json.loads(response.body)
+ assert isinstance(body, dict)
+ if response.status == 200:
+ assert "instance" in body
+ assert isinstance(body["instance"].get("share_instance"), bool)
+ assert isinstance(body["instance"].get("local_hops_delta"), bool)
+
+
+def test_build_reticulum_instance_settings_prefers_config_over_live():
+ app = MagicMock()
+ app._get_reticulum_section = lambda: {
+ "share_instance": "No",
+ "local_hops_delta": "No",
+ "rpc_key": "aa" * 32,
+ "instance_name": "default",
+ }
+ app._parse_rns_config_bool = ReticulumMeshChat._parse_rns_config_bool
+ app._get_reticulum_rpc_key_hex = lambda: (
+ ReticulumMeshChat._get_reticulum_rpc_key_hex(app)
+ )
+ app.reticulum = MagicMock()
+ app.reticulum.share_instance = True
+ app.reticulum.is_connected_to_shared_instance = False
+ app.reticulum.transport_enabled = MagicMock(return_value=True)
+ app.reticulum.rpc_key = None
+
+ settings = ReticulumMeshChat._build_reticulum_instance_settings(app)
+ assert settings["share_instance"] is False
+ assert settings["local_hops_delta"] is False
+ assert settings["rpc_config_snippet"]
+ assert "rpc_key = " in settings["rpc_config_snippet"]
diff --git a/tests/backend/test_rrc_protocol.py b/tests/backend/test_rrc_protocol.py
index 6062eb4f..2138eaf0 100644
--- a/tests/backend/test_rrc_protocol.py
+++ b/tests/backend/test_rrc_protocol.py
@@ -263,6 +263,87 @@ def test_welcome_sends_list_when_auto_list_enabled(tmp_path):
assert calls == ["/list"]
+def test_silent_self_join_records_rejoined_system_line(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub._pending_joins.add("lobby")
+ hub._silent_joins.add("lobby")
+ env = proto.make_envelope(
+ proto.T_JOINED,
+ src=None,
+ room="lobby",
+ body=[manager.identity.hash],
+ nick="me",
+ )
+
+ hub._handle_joined(env)
+
+ msgs = hub.messages.get("lobby", [])
+ assert any(m.kind == "system" and m.text == "You rejoined #lobby" for m in msgs)
+
+
+def test_manual_self_join_records_joined_system_line(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub._pending_joins.add("lobby")
+ env = proto.make_envelope(
+ proto.T_JOINED,
+ src=None,
+ room="lobby",
+ body=[manager.identity.hash],
+ nick="me",
+ )
+
+ hub._handle_joined(env)
+
+ msgs = hub.messages.get("lobby", [])
+ assert any(m.kind == "system" and m.text == "You joined #lobby" for m in msgs)
+
+
+def test_connection_lost_records_system_line_in_joined_rooms(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub.welcomed = True
+ hub.rooms.add("lobby")
+ hub.messages["lobby"] = []
+ hub.auto_reconnect = False
+
+ hub._on_closed(object())
+
+ msgs = hub.messages.get("lobby", [])
+ assert any(m.kind == "system" and m.text == "Connection lost" for m in msgs)
+
+
+def test_manual_disconnect_records_disconnected_system_line(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub.welcomed = True
+ hub.rooms.add("lobby")
+ hub.messages["lobby"] = []
+ hub._manual_disconnect = True
+ hub.auto_reconnect = False
+
+ hub._on_closed(object())
+
+ msgs = hub.messages.get("lobby", [])
+ assert any(m.kind == "system" and m.text == "Disconnected from hub" for m in msgs)
+
+
+def test_welcome_after_session_records_reconnected(tmp_path):
+ manager = make_manager(tmp_path)
+ hub = manager.add_hub(bytes(range(16)))
+ hub.rooms.add("lobby")
+ hub.messages["lobby"] = []
+ hub._had_session = True
+ hub.auto_list = False
+ hub.auto_who = False
+
+ hub._handle_welcome({})
+
+ msgs = hub.messages.get("lobby", [])
+ assert any(m.kind == "system" and m.text == "Reconnected to hub" for m in msgs)
+
+
def test_set_auto_list_requests_list_immediately_when_already_connected(tmp_path):
"""Enabling auto-list mid-session must not require a reconnect to take effect."""
manager = make_manager(tmp_path)
diff --git a/tests/backend/test_rrc_server.py b/tests/backend/test_rrc_server.py
index d30b0eca..0e119204 100644
--- a/tests/backend/test_rrc_server.py
+++ b/tests/backend/test_rrc_server.py
@@ -446,3 +446,37 @@ def test_manager_save_roundtrip(tmp_path):
assert obj["hubs"][0]["name"] == "Saved Hub"
assert obj["hubs"][0]["rooms"][0]["name"] == "general"
assert manager.find_hub(HUB_HASH.hex()) is hub
+
+
+def test_announce_interval_defaults_and_clamps():
+ from meshchatx.src.backend.rrc.server import (
+ DEFAULT_ANNOUNCE_INTERVAL_SECONDS,
+ normalize_announce_interval_seconds,
+ )
+
+ assert (
+ normalize_announce_interval_seconds(None) == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ )
+ assert normalize_announce_interval_seconds(0) == 0
+ assert normalize_announce_interval_seconds(-5) == 0
+ assert normalize_announce_interval_seconds(30) == 60
+ assert normalize_announce_interval_seconds(999999) == 86400
+ server = make_server()
+ assert server.announce_interval_seconds == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ assert (
+ server.to_dict()["announce_interval_seconds"]
+ == DEFAULT_ANNOUNCE_INTERVAL_SECONDS
+ )
+
+
+def test_set_announce_settings_syncs_timer():
+ server = make_running_server()
+ server.set_announce_settings(announce=True, announce_interval_seconds=120)
+ assert server.announce is True
+ assert server.announce_interval_seconds == 120
+ assert server._announce_timer is not None
+ server.set_announce_settings(announce=False)
+ assert server.announce is False
+ assert server._announce_timer is None
+ server.set_announce_settings(announce=True, announce_interval_seconds=0)
+ assert server._announce_timer is None
diff --git a/tests/backend/test_self_check.py b/tests/backend/test_self_check.py
index 2ab2ee4f..d67d63ae 100644
--- a/tests/backend/test_self_check.py
+++ b/tests/backend/test_self_check.py
@@ -134,8 +134,23 @@ def test_check_web_stack_ok(mock_app, require_loopback_tcp):
mock_app.current_context.message_router.propagation_destination.hexhash = "b" * 32
if getattr(mock_app, "reticulum", None) is not None:
mock_app.reticulum.is_connected_to_shared_instance = False
+ mock_app.reticulum.share_instance = True
mock_app.reticulum.transport_enabled = MagicMock(return_value=False)
mock_app.reticulum.get_path_table = MagicMock(return_value=[])
+
+ class _Cfg(dict):
+ def write(self):
+ return True
+
+ mock_app.reticulum.config = _Cfg(
+ {
+ "reticulum": {
+ "share_instance": "Yes",
+ "local_hops_delta": "No",
+ },
+ "interfaces": {},
+ },
+ )
# WebSocket handler awaits these.
mock_app.send_config_to_websocket_clients = AsyncMock(return_value=None)
mock_app.websocket_broadcast = AsyncMock()
diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js
index 50594b08..65565202 100644
--- a/tests/frontend/ConversationViewerButtons.test.js
+++ b/tests/frontend/ConversationViewerButtons.test.js
@@ -6,7 +6,7 @@ import DialogUtils from "@/js/DialogUtils";
import GlobalEmitter from "@/js/GlobalEmitter";
import GlobalState from "@/js/GlobalState";
-const RENDER_THRESHOLD_MS = 500;
+const RENDER_THRESHOLD_MS = 1500;
vi.mock("@/js/DialogUtils", () => ({
default: {
diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index fdbff628..e1dacf6a 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -14,6 +14,8 @@ function makeHostedHub(overrides = {}) {
enabled: true,
running: true,
announce: true,
+ announce_interval_seconds: 900,
+ uptime_seconds: 120,
greeting: null,
clients: 0,
rooms: [{ name: "lobby", topic: "Chat", private: false, registered: true, members: 0 }],
@@ -377,16 +379,65 @@ describe("RelayChatPage.vue", () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
- wrapper.vm.createHubForm = { name: "Fresh Hub", greeting: "hi", announce: true };
+ wrapper.vm.createHubForm = {
+ name: "Fresh Hub",
+ greeting: "hi",
+ announce: true,
+ announce_interval_seconds: 1800,
+ };
await wrapper.vm.createServerHub();
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/rrc/servers", {
name: "Fresh Hub",
greeting: "hi",
announce: true,
+ announce_interval_seconds: 1800,
});
});
+ it("saves hosted hub settings via the API", async () => {
+ axiosMock.patch.mockResolvedValueOnce({ data: { hub: makeHostedHub({ name: "Renamed" }) } });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
+
+ wrapper.vm.openHostHubSettings(wrapper.vm.serverHubs[0]);
+ wrapper.vm.hostHubSettingsForm = {
+ name: "Renamed",
+ announce: false,
+ announce_interval_seconds: 900,
+ };
+ await wrapper.vm.saveHostHubSettings();
+
+ expect(axiosMock.patch).toHaveBeenCalledWith(`/api/v1/rrc/servers/${HOSTED_HUB_ID}`, {
+ name: "Renamed",
+ announce: false,
+ announce_interval_seconds: 900,
+ });
+ });
+
+ it("toggles available rooms collapse and persists layout", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/rrc/hubs") {
+ return Promise.resolve({
+ data: { hubs: [makeHub({ available_rooms: { lobby: "Main", random: null } })] },
+ });
+ }
+ if (url === "/api/v1/rrc/servers") {
+ return Promise.resolve({ data: { hubs: [] } });
+ }
+ if (url === "/api/v1/announces") {
+ return Promise.resolve({ data: { announces: [] } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+ expect(wrapper.vm.isAvailableRoomsExpanded(HUB_HASH)).toBe(true);
+ wrapper.vm.toggleAvailableRooms(HUB_HASH);
+ expect(wrapper.vm.isAvailableRoomsExpanded(HUB_HASH)).toBe(false);
+ expect(wrapper.vm.availableRoomsExpanded[HUB_HASH]).toBe(false);
+ });
+
it("creates a room on a hosted hub via the API", async () => {
const wrapper = mountPage();
await vi.waitFor(() => expect(wrapper.vm.serverHubs.length).toBe(1));
diff --git a/tests/frontend/announceIntervalSliderMap.test.js b/tests/frontend/announceIntervalSliderMap.test.js
new file mode 100644
index 00000000..d4d54b93
--- /dev/null
+++ b/tests/frontend/announceIntervalSliderMap.test.js
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import {
+ ANNOUNCE_SLIDER_POS_MAX,
+ announceMinutesToSliderPos,
+ announceSliderPosToMinutes,
+} from "@/js/announceIntervalSliderMap.js";
+
+describe("announceIntervalSliderMap", () => {
+ it("maps endpoints", () => {
+ expect(announceSliderPosToMinutes(0)).toBe(1);
+ expect(announceSliderPosToMinutes(ANNOUNCE_SLIDER_POS_MAX)).toBe(1440);
+ expect(announceMinutesToSliderPos(1)).toBeGreaterThanOrEqual(0);
+ expect(announceMinutesToSliderPos(1440)).toBeLessThanOrEqual(ANNOUNCE_SLIDER_POS_MAX);
+ });
+
+ it("round-trips common minute values", () => {
+ for (const minutes of [1, 5, 15, 30, 60, 120, 360, 720, 1440]) {
+ const pos = announceMinutesToSliderPos(minutes);
+ expect(announceSliderPosToMinutes(pos)).toBe(minutes);
+ }
+ });
+
+ it("increases monotonically across the track", () => {
+ let prev = announceSliderPosToMinutes(0);
+ for (let pos = 1; pos <= ANNOUNCE_SLIDER_POS_MAX; pos += 17) {
+ const next = announceSliderPosToMinutes(pos);
+ expect(next).toBeGreaterThanOrEqual(prev);
+ prev = next;
+ }
+ });
+});
diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 4ae225ea..4a26f392 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -164,3 +164,32 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
expect(initPy.length).toBeGreaterThan(0);
});
});
+
+describe("behavior contracts: Reticulum instance settings", () => {
+ it("Settings transport section wires Sideband-parity instance controls", () => {
+ const page = readSource("meshchatx/src/frontend/components/settings/SettingsPage.vue");
+ expect(page).toContain("share-reticulum-instance");
+ expect(page).toContain("obfuscate-hops");
+ expect(page).toContain("copyRpcConfigSnippet");
+ expect(page).toContain("fetchReticulumInstanceSettings");
+ expect(page).toContain("applyReticulumInstanceSettings");
+ const service = readSource("meshchatx/src/frontend/js/settings/settingsReticulumInstanceService.js");
+ expect(service).toContain("/api/v1/reticulum/instance");
+ const selfCheck = readSource("meshchatx/src/backend/self_check.py");
+ expect(selfCheck).toContain("http_reticulum_instance_good");
+ expect(selfCheck).toContain("/api/v1/reticulum/instance");
+ });
+});
+
+describe("behavior contracts: network visualiser performance", () => {
+ it("keeps lean physics and edge-hide options for large meshes", () => {
+ const src = readSource("meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue");
+ expect(src).toContain("hideEdgesOnDrag: true");
+ expect(src).toContain("hideEdgesOnZoom: true");
+ expect(src).toMatch(/avoidOverlap:\s*0/);
+ expect(src).toContain('solver: "barnesHut"');
+ const perf = readSource("meshchatx/src/frontend/js/networkVisualiserPerf.js");
+ expect(perf).toContain("dedupeIconQueueEntries");
+ expect(perf).toContain("pickAdaptiveFetchConcurrency");
+ });
+});
diff --git a/tests/frontend/relayMessageSearch.test.js b/tests/frontend/relayMessageSearch.test.js
index 8b3bb753..6abe26e8 100644
--- a/tests/frontend/relayMessageSearch.test.js
+++ b/tests/frontend/relayMessageSearch.test.js
@@ -81,6 +81,51 @@ describe("relayMessageTimeline", () => {
expect(timeline.filter((e) => e.type === "message")).toHaveLength(2);
});
+ it("collapses consecutive join/leave system lines into a presence group", () => {
+ const day = new Date("2026-06-02T12:00:00").getTime();
+ const timeline = buildRelayMessageTimeline([
+ { kind: "msg", ts: day, src: "a", text: "hi", seq: 1 },
+ { kind: "system", ts: day + 1, text: "nickie left", seq: 2 },
+ { kind: "system", ts: day + 2, text: "jrrz left", seq: 3 },
+ { kind: "system", ts: day + 3, text: "jrrz joined", seq: 4 },
+ { kind: "msg", ts: day + 4, src: "b", text: "yo", seq: 5 },
+ { kind: "system", ts: day + 5, text: "solo joined", seq: 6 },
+ ]);
+ const types = timeline.filter((e) => e.type !== "dateDivider").map((e) => e.type);
+ expect(types).toEqual(["message", "presenceGroup", "message", "message"]);
+ const group = timeline.find((e) => e.type === "presenceGroup");
+ expect(group.joinedCount).toBe(1);
+ expect(group.leftCount).toBe(2);
+ expect(group.messages).toHaveLength(3);
+ expect(timeline.filter((e) => e.type === "message").at(-1).msg.text).toBe("solo joined");
+ });
+
+ it("treats You joined / You rejoined as presence events", () => {
+ const day = new Date("2026-06-02T12:00:00").getTime();
+ const timeline = buildRelayMessageTimeline([
+ { kind: "system", ts: day, text: "You rejoined #lobby", seq: 1 },
+ { kind: "system", ts: day + 1, text: "alice joined", seq: 2 },
+ { kind: "system", ts: day + 2, text: "You joined #lobby", seq: 3 },
+ ]);
+ const group = timeline.find((e) => e.type === "presenceGroup");
+ expect(group).toBeTruthy();
+ expect(group.joinedCount).toBe(3);
+ expect(group.leftCount).toBe(0);
+ });
+
+ it("collapses connection lost/reconnected lines with presence events", () => {
+ const day = new Date("2026-06-02T12:00:00").getTime();
+ const timeline = buildRelayMessageTimeline([
+ { kind: "system", ts: day, text: "Connection lost", seq: 1 },
+ { kind: "system", ts: day + 1, text: "Reconnected to hub", seq: 2 },
+ { kind: "system", ts: day + 2, text: "You rejoined #lobby", seq: 3 },
+ ]);
+ const group = timeline.find((e) => e.type === "presenceGroup");
+ expect(group).toBeTruthy();
+ expect(group.connectionCount).toBe(2);
+ expect(group.joinedCount).toBe(1);
+ });
+
it("relayMessageKey is stable", () => {
const msg = { kind: "msg", ts: 1, src: "ab", text: "x" };
expect(relayMessageKey(msg)).toBe(relayMessageKey(msg));
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────